데드락이 생기는 이유와 재시도 전략

데드락이 생기는 이유와 재시도 전략

한눈에 보기

데드락은 두 트랜잭션이 서로가 가진 잠금을 기다려 어느 쪽도 진행할 수 없는 상태다. DB는 이를 감지하면 트랜잭션 하나를 희생자로 골라 롤백한다. 따라서 데드락은 “절대 발생하면 안 되는 예외”라기보다 줄이되 완전히 배제할 수 없는 동시성 사건이며, 애플리케이션은 트랜잭션 전체를 제한적으로 다시 실행할 준비가 필요하다.

목차

문제가 되는 상황

계좌 A에서 B로 포인트를 옮기는 기능이 있다고 하자. 송금 요청은 출금 계좌와 입금 계좌를 차례로 잠근다.

async function transfer(fromId: number, toId: number, amount: number) {
  return database.transaction(async (tx) => {
    const from = await tx.accounts.findForUpdate(fromId);
    const to = await tx.accounts.findForUpdate(toId);

    if (from.balance < amount) {
      throw new InsufficientBalanceError();
    }

    await tx.accounts.decrease(fromId, amount);
    await tx.accounts.increase(toId, amount);
  });
}

이 코드 한 번만 보면 문제가 없어 보인다. 하지만 다음 두 요청이 동시에 실행되면 상황이 달라진다.

요청 1: transfer(10, 20, 1000)
요청 2: transfer(20, 10, 500)

요청 1은 10번 계좌를 먼저 잠그고 20번을 기다린다. 요청 2는 20번 계좌를 먼저 잠그고 10번을 기다린다. 둘 다 상대가 커밋해야 필요한 잠금을 얻을 수 있지만, 어느 쪽도 다음 단계로 진행할 수 없다.

두 트랜잭션이 원형으로 기다리는 과정

시간 순서로 펼치면 원인이 더 분명해진다.

sequenceDiagram
    participant T1 as Transaction 1
    participant A10 as Account 10
    participant A20 as Account 20
    participant T2 as Transaction 2
    T1->>A10: X lock 획득
    T2->>A20: X lock 획득
    T1->>A20: X lock 요청
    Note over T1,A20: T2를 기다림
    T2->>A10: X lock 요청
    Note over T2,A10: T1을 기다림

DB 내부의 관계는 대기 그래프(wait-for graph)로 생각할 수 있다.

flowchart LR
    T1[Transaction 1] -->|Account 20 잠금 대기| T2[Transaction 2]
    T2 -->|Account 10 잠금 대기| T1

그래프에 T1 → T2 → T1 순환이 생겼다. 이 순환이 데드락의 핵심이다. 두 트랜잭션뿐 아니라 세 개 이상이 고리로 연결될 수도 있다.

데드락은 테이블 잠금에서만 생기지 않는다

행 잠금, 인덱스 레코드 잠금, gap lock, 외래 키 검사 과정의 잠금 등 서로 호환되지 않는 자원을 반대 순서로 획득하면 발생할 수 있다. 코드에 같은 테이블이 두 번 보이지 않는다고 안심할 수 없다.

일반 잠금 대기와 데드락의 차이

잠금을 기다린다고 모두 데드락은 아니다. 트랜잭션 A가 행을 잠근 채 작업하고 트랜잭션 B가 그 잠금을 기다려도, A가 독립적으로 커밋할 수 있다면 정상적인 잠금 대기다.

상태 관계 종료 방식
일반 잠금 대기 B가 A를 기다리지만 A는 진행 가능 A가 커밋하면 B가 계속 실행
데드락 A와 B가 서로를 기다리는 순환 존재 DB가 하나를 롤백해야만 해소
lock wait timeout 잠금 대기가 설정 시간을 초과 기다리던 트랜잭션이 오류로 종료

운영 로그에서 “lock wait timeout”을 봤다고 반드시 데드락으로 분류하면 안 된다. 데드락은 DB가 순환을 감지한 사건이고, timeout은 단순히 오래 기다렸다는 뜻이다. 대응도 다르다. 전자는 잠금 순서 분석이 중요하고, 후자는 긴 트랜잭션이나 느린 쿼리를 먼저 찾아야 할 수 있다.

DB는 데드락을 어떻게 풀까

순환 상태는 기다리기만 해서는 풀리지 않는다. DB는 대기 관계를 검사해 데드락을 발견하면 참여 트랜잭션 중 하나를 희생자(victim)로 선택하고 롤백한다. 그러면 그 트랜잭션이 보유하던 잠금이 해제되어 나머지가 진행한다.

Transaction 1  ──┐
                 ├─ deadlock detected
Transaction 2  ──┘

→ Transaction 2 rollback
→ Transaction 2의 lock 해제
→ Transaction 1이 lock 획득 후 진행

어느 트랜잭션이 희생되는지는 애플리케이션이 확정적으로 예측해서는 안 된다. DB는 롤백 비용 등 내부 기준으로 선택할 수 있고 버전과 구현에 따라 세부 동작이 달라진다. “항상 나중에 시작한 요청이 실패한다” 같은 가정은 위험하다.

한 요청이 실패했다는 것은 DB가 정상적으로 안전장치를 작동시킨 결과다

DB가 두 트랜잭션을 모두 커밋해 잘못된 결과를 남기는 대신 하나를 중단한 것이다. 애플리케이션은 이 실패를 분류해 다시 시도하거나 사용자에게 적절한 결과를 반환해야 한다.

잠금 순서를 통일한다

가장 먼저 적용할 예방책은 모든 코드 경로가 자원을 같은 순서로 잠그게 만드는 것이다. 송금 방향과 관계없이 작은 계좌 ID부터 잠그도록 바꿀 수 있다.

async function lockAccounts(
  tx: Transaction,
  firstAccountId: number,
  secondAccountId: number,
) {
  const ids = [firstAccountId, secondAccountId].sort((a, b) => a - b);

  return tx.accounts.findAllForUpdate(ids);
}

쿼리에서도 접근 순서를 명시한다.

SELECT id, balance
FROM accounts
WHERE id IN (:first_id, :second_id)
ORDER BY id
FOR UPDATE;

이제 두 요청 모두 10번을 먼저 잠그려 한다. 하나는 10번을 얻고 계속 진행하며, 다른 하나는 첫 단계에서 기다린다. 원형 대기가 만들어지지 않는다.

다만 서비스 메서드 하나만 고쳐서는 부족하다. 계좌를 수정하는 배치, 관리자 기능, 정산 작업도 같은 순서를 따라야 한다. 여러 테이블을 다룬다면 “주문 → 결제 → 포인트”처럼 시스템 차원의 잠금 순서를 문서화하는 편이 좋다.

트랜잭션의 범위와 접근 행 수를 줄인다

잠금을 오래, 많이 가질수록 다른 트랜잭션과 교차할 기회가 늘어난다. 다음 작업은 트랜잭션 밖에서 먼저 끝낼 수 있다.

// 트랜잭션 전에 수행 가능한 작업
validateTransferRequest(input);
const policy = await policyCache.get(input.memberId);

// 공유 데이터를 바꾸는 최소 구간만 트랜잭션으로 묶는다.
await database.transaction(async (tx) => {
  const accounts = await lockAccounts(tx, input.fromId, input.toId);
  assertTransferAllowed(accounts, policy, input.amount);
  await applyTransfer(tx, input);
});

인덱스 역시 중요하다. 조건을 지원하는 인덱스가 없으면 쿼리가 더 많은 레코드를 탐색하고 잠글 수 있다. EXPLAIN으로 접근 경로를 확인하고, 데드락 로그에 표시된 인덱스가 의도한 것인지 살펴야 한다.

실패한 SQL 한 줄만 재시도하면 안 된다

DB가 데드락 희생자를 롤백하면 해당 트랜잭션에서 앞서 실행한 변경도 모두 취소된다. 따라서 오류가 난 마지막 UPDATE만 다시 실행하면 안 된다. 새로운 트랜잭션을 시작해 필요한 데이터를 다시 읽고 비즈니스 조건도 다시 검사해야 한다.

async function runTransactionWithRetry<T>(
  operation: () => Promise<T>,
  maxAttempts = 3,
): Promise<T> {
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      // operation이 호출될 때마다 새 트랜잭션을 만든다.
      return await operation();
    } catch (error) {
      const retryable = isDeadlockError(error);

      if (!retryable || attempt === maxAttempts) {
        throw error;
      }

      await delay(calculateBackoffWithJitter(attempt));
    }
  }

  throw new Error("unreachable");
}

호출부는 트랜잭션 전체를 함수로 감싼다.

await runTransactionWithRetry(() =>
  database.transaction(async (tx) => {
    const accounts = await lockAccounts(tx, fromId, toId);
    assertSufficientBalance(accounts, fromId, amount);
    await applyTransfer(tx, { fromId, toId, amount });
  }),
);
모든 DB 오류를 재시도하지 않는다

문법 오류, 제약 조건 위반, 인증 실패는 반복해도 성공하지 않는다. 드라이버가 제공하는 오류 코드와 SQLSTATE를 기준으로 데드락, 직렬화 실패처럼 일시적인 오류만 명시적으로 분류한다. 문자열 메시지 전체를 비교하면 DB 버전이나 언어 설정에 쉽게 깨진다.

재시도 코드를 안전하게 만드는 조건

횟수를 제한하고 지터를 넣는다

즉시 무한 재시도하면 같은 요청들이 같은 타이밍에 다시 충돌하며 DB 부하를 키운다. 작은 지수 백오프와 무작위 지연을 사용하고 최대 횟수를 제한한다.

function calculateBackoffWithJitter(attempt: number): number {
  const baseMs = 20;
  const capMs = 500;
  const exponential = Math.min(capMs, baseMs * 2 ** (attempt - 1));

  return Math.floor(Math.random() * exponential);
}

구체적인 백오프 전략은 재시도에 지수 백오프와 지터가 필요한 이유에서 더 자세히 다룬다.

트랜잭션 안의 부수 효과를 분리한다

DB 트랜잭션 안에서 이메일이나 메시지를 전송한 뒤 데드락으로 롤백되면, 재시도 과정에서 같은 메시지가 두 번 발송될 수 있다. 외부 부수 효과는 커밋 이후에 실행하거나 outbox 패턴으로 분리해야 한다.

START TRANSACTION;

UPDATE accounts
SET balance = balance - :amount
WHERE id = :from_id;

INSERT INTO outbox_events(event_id, event_type, payload)
VALUES (:event_id, 'TRANSFER_COMPLETED', :payload);

COMMIT;

이후 별도 워커가 outbox를 읽어 메시지를 발행한다. 소비자도 event_id를 기준으로 중복을 무시하도록 만들면 재시도와 at-least-once 전달에 견딜 수 있다.

요청 자체에 멱등성 키를 둔다

클라이언트가 응답을 받지 못해 동일 송금을 다시 요청하는 문제는 DB 데드락 재시도와 별개로 존재한다. 요청 ID에 유니크 제약을 두어 같은 비즈니스 작업이 두 번 적용되지 않게 한다.

CREATE UNIQUE INDEX uq_transfers_request_id
ON transfers(request_id);

데드락을 재현하고 원인을 찾는 방법

운영에서 간헐적으로 발생하는 문제는 로컬의 두 DB 세션으로 순서를 재현하면 이해하기 쉽다.

세션 A:

START TRANSACTION;
UPDATE accounts SET memo = 'A' WHERE id = 10;
-- 아직 COMMIT하지 않는다.

세션 B:

START TRANSACTION;
UPDATE accounts SET memo = 'B' WHERE id = 20;
-- 아직 COMMIT하지 않는다.

다시 세션 A에서 20번을, 세션 B에서 10번을 수정한다.

-- session A: B가 가진 잠금을 기다린다.
UPDATE accounts SET memo = 'A2' WHERE id = 20;

-- session B: A가 가진 잠금을 요청해 순환을 만든다.
UPDATE accounts SET memo = 'B2' WHERE id = 10;

DB가 한 세션에 데드락 오류를 반환하면 재현에 성공한 것이다. 조사할 때는 오류 시각, 관련 트랜잭션의 SQL, 잠근 인덱스와 레코드, 각 트랜잭션이 이미 보유한 잠금을 함께 봐야 한다. 애플리케이션 로그에는 요청 ID와 트랜잭션 작업 이름을 남겨 DB 로그와 연결한다.

남길 항목 이유
operation 이름 어떤 업무 흐름에서 발생했는지 식별
DB 오류 코드 재시도 가능한 오류인지 안정적으로 분류
attempt 번호 재시도가 반복되는지 확인
request 또는 trace ID 분산 로그와 DB 사건 연결
소요 시간 잠금 대기와 전체 응답 지연 비교
SQL 파라미터를 무분별하게 로그에 남기지 않는다

계좌, 사용자, 결제 정보 같은 민감한 값이 포함될 수 있다. 원인 분석에 필요한 식별자는 마스킹하거나 내부 추적 ID로 대체한다.

데드락이 아닌 문제와 구분한다

재시도 코드를 넣기 전에 정말 데드락인지 확인한다.

이들은 사용자에게 비슷한 “요청 실패”로 보이지만 병목과 해결책이 다르다. 특히 긴 트랜잭션을 그대로 둔 채 재시도 횟수만 늘리면 더 많은 요청이 잠금 경쟁에 합류해 상황이 악화될 수 있다.

결론

데드락은 서로 다른 잠금 획득 순서가 원형 대기를 만들 때 발생하며, DB는 안전을 위해 트랜잭션 하나를 롤백해 순환을 끊는다. 예방의 출발점은 모든 코드 경로의 잠금 순서를 통일하고, 트랜잭션을 짧게 유지하며, 접근할 행과 인덱스를 명확히 하는 것이다.

그럼에도 데드락을 0으로 가정해서는 안 된다. 애플리케이션은 정확한 오류 코드로 일시 오류를 분류하고, 새로운 트랜잭션에서 읽기부터 전체 작업을 제한적으로 재시도해야 한다. 외부 부수 효과와 요청 중복까지 안전하게 만들려면 outbox와 멱등성 키도 함께 설계해야 한다. 마지막으로 재시도 횟수를 늘리는 것보다 반복되는 대기 그래프를 찾아 잠금 순서와 쿼리를 고치는 것이 근본적인 해결이다.

관련 노트